Michele Volpato

Michele Volpato

Flutter full app 1. Music Player: create a simple Flutter music player app

Tutorials Flutter
Flutter Dart just_audio
1.22.6 2.10.5 0.6.13

Today I decided to start a little project to show how to create a full app in Flutter, from an initial proof of concept, to testing, to improving UX and UI, to (light) project management. Hopefully, the app will be integrated with additional features as I publish more articles.

The idea is to create an audio (music/podcast) player that you can use to play audio from the internet. In this first article, we start by using just_audio, a package for playing audio by Ryan Heise.

Setup the project

We start by creating the Flutter project with

flutter create music_player

then we change the app version, in pubspec.yaml from 1.0.0+1 to 0.0.1+1, because we will use 1.0.0 for the first full release. While we are in pubspec.yaml we can also add the just_audio package dependency:

  # Play music files
  just_audio: ^0.6.12

Add a play button

Now it is time to start with coding. The very first goal is to have a button that, when pressed, starts playing music. So, create a new folder in lib, called screens. This is the folder where most of the files related to the UI will reside. In screens create a Dart file called player.dart. Create a new stateful widget, with a private property called _audioPlayer of type AudioPlayer, which is defined in just_audio.

In void iniState() we are going to initialize the audio player, and in void dispose()we are going to dispose of it, as per documentation.

So this is the code up until now:

import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';

class Player extends StatefulWidget {
  @override
  _PlayerState createState() => _PlayerState();
}

class _PlayerState extends State<Player> {
  AudioPlayer _audioPlayer;

  @override
  void initState() {
    super.initState();
    _audioPlayer = AudioPlayer();

    // Set a sequence of audio sources that will be played by the audio player.
    _audioPlayer
        .setAudioSource(ConcatenatingAudioSource(children: [
      AudioSource.uri(Uri.parse(
          "https://archive.org/download/IGM-V7/IGM%20-%20Vol.%207/25%20Diablo%20-%20Tristram%20%28Blizzard%29.mp3")),
      AudioSource.uri(Uri.parse(
          "https://archive.org/download/igm-v8_202101/IGM%20-%20Vol.%208/15%20Pokemon%20Red%20-%20Cerulean%20City%20%28Game%20Freak%29.mp3")),
      AudioSource.uri(Uri.parse(
          "https://scummbar.com/mi2/MI1-CD/01%20-%20Opening%20Themes%20-%20Introduction.mp3")),
    ]))
        .catchError((error) {
      // catch load errors: 404, invalid url ...
      print("An error occured $error");
    });
  }

  @override
  void dispose() {
    _audioPlayer.dispose();
    super.dispose();
  }
}

An AudioSource can be a remote file, like in the code above, or a local file.

We need a button to be able to start and stop the audio player. We are going to use a method to discriminate between the different states of the app: playing, paused, completed.

Create a private method Widget _playerButton(PlayerState playerState).

PlayerState is a class defined by just_audio that holds information about, you guess it, the state of the audio player. More in details it

/// Encapsulates the playing and processing states. These two states vary
/// orthogonally, and so if [processingState] is [ProcessingState.buffering],
/// you can check [playing] to determine whether the buffering occurred while
/// the player was playing or while the player was paused.

This is the code we are going to add to it.

   Widget _playerButton(PlayerState playerState) {

    // 1
    final processingState = playerState?.processingState;
    if (processingState == ProcessingState.loading ||
        processingState == ProcessingState.buffering) {
        
      // 2
      return Container(
        margin: EdgeInsets.all(8.0),
        width: 64.0,
        height: 64.0,
        child: CircularProgressIndicator(),
      );
    } else if (_audioPlayer.playing != true) {
    
      // 3
      return IconButton(
        icon: Icon(Icons.play_arrow),
        iconSize: 64.0,
        onPressed: _audioPlayer.play,
      );
    } else if (processingState != ProcessingState.completed) {
    
      // 4
      return IconButton(
        icon: Icon(Icons.pause),
        iconSize: 64.0,
        onPressed: _audioPlayer.pause,
      );
    } else {
    
      // 5
      return IconButton(
        icon: Icon(Icons.replay),
        iconSize: 64.0,
        onPressed: () => _audioPlayer.seek(Duration.zero,
            index: _audioPlayer.effectiveIndices.first),
      );
    }
  }
  1. extracts the processing state, which is one among idle, loading, buffering, ready, and completed;
  2. if the player is in a temporary state, like loading and buffering, the button is replaced by a loading indicator;
  3. otherwise, if the audio player is not playing anything, which means that it is either paused or not started yet, the button is a play button that invokes the play()method on the audio player;
  4. otherwise, if the state is not completed, then the audio player is playing one of the audio sources and the button is a pause button that invokes pause() on the audio player;
  5. finally, if all the above are not true, then the player has finished playing the sequence of audio sources, and it is still playing nothing, and the button is a replay button that moves the head of the player to the very beginning of the first audio source. Given that the player is playing, but it reached the end of the sequence, the music will start immediately, without the need to invoke an additional play().

The only thing missing now is the build method:

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: StreamBuilder<PlayerState>(
        stream: _audioPlayer.playerStateStream,
        builder: (context, snapshot) {
          final playerState = snapshot.data;
          return _playerButton(playerState);
        },
      ),
    ),
  );
}

just_audio provides some handy streams to emit the state and data to listeners. One of them is playerStateStream, which emits PlayerState objects, just what is needed in _playerButton. A StreamBuilder is the perfect widget to handle the state and build the button.

Now we need to refer to this widget in main.dart.

// main.dart

import 'package:flutter/material.dart';
import 'package:music_player/screens/player.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Player(),
    );
  }
}

Add other buttons

Run the app, and try it. It works. But it is a bit sad: just a button in the middle of the screen.

Just a play button

We will need to add more buttons to make it more interesting. First of all, we are going to separate the buttons UI from the player screen. We might want to use the same UI in some other screens in the future.

Create a new folder in screens called commons, and add in it a new file called player_buttons.dart. The widget in this file is a StatelessWidget, because it will use streams to handle the state.

Player needed to be stateful because it needs to handle initialization and disposalof the audio player, but in this widget we can just inject the player.

In the future, we are going to change how we initialize and dispose of the audio player, and how we inject it into those widgets that need it, but for now, this is enough.

Move _playerButton to the new file and rename it _playPauseButton, to avoid confusion when we are going to add more buttons.

We want to create:

  1. a shuffle button, which will enable or disable shuffle mode, using _audioPlayer.shuffle() to change the order of the audio sources that did not play yet, and _audioPlayer.setShuffleModeEnabled(true) to set the mode of the player to shuffle.
  2. a previous button, that will be enabled if there is a previous audio source, and will load that source when pressed, using _audioPlayer.seekToPrevious();
  3. a next button, that will be enabled if there is a next audio source, and will load that source when pressed, using _audioPlayer.seekToNext();
  4. a loop button, which will cycle among not looping, looping one audio source, and looping the entire sequence of audio sources, using _audioPlayer.setLoopMode(_).

AudioPlayer provides streams for 1 and 4. For 2 and 3 we will need to observe the SequenceState that will emit every time either the sequence of audio sources changes, for instance because the shuffle mode changed, or the current playing audio source changes.

In the build function of PlayerButtons add

@override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        StreamBuilder<bool>(
          stream: _audioPlayer.shuffleModeEnabledStream,
          builder: (context, snapshot) {
            return _shuffleButton(context, snapshot.data ?? false);
          },
        ),
        StreamBuilder<SequenceState>(
          stream: _audioPlayer.sequenceStateStream,
          builder: (_, __) {
            return _previousButton();
          },
        ),
        StreamBuilder<PlayerState>(
          stream: _audioPlayer.playerStateStream,
          builder: (_, snapshot) {
            final playerState = snapshot.data;
            return _playPauseButton(playerState);
          },
        ),
        StreamBuilder<SequenceState>(
          stream: _audioPlayer.sequenceStateStream,
          builder: (_, __) {
            return _nextButton();
          },
        ),
        StreamBuilder<LoopMode>(
          stream: _audioPlayer.loopModeStream,
          builder: (context, snapshot) {
            return _repeatButton(context, snapshot.data ?? LoopMode.off);
          },
        ),
      ],
    );
  }

We added a row, and for each button, we add a stream builder. The first observers the shuffle state of the audio player, the second and fourth observe the sequence state, and the fifth observes the state of the loop mode. The third one is the play button we already build in the previous section.

Now we only need to create four missing methods. The first method is the one that creates a widget for the shuffle mode.

Widget _shuffleButton(BuildContext context, bool isEnabled) {
    return IconButton(
      icon: isEnabled
          ? Icon(Icons.shuffle, color: Theme.of(context).accentColor)
          : Icon(Icons.shuffle),
      onPressed: () async {
        final enable = !isEnabled;
        if (enable) {
          await _audioPlayer.shuffle();
        }
        await _audioPlayer.setShuffleModeEnabled(enable);
      },
    );
}

The second and the third methods create the previous and next buttons.

Widget _previousButton() {
    return IconButton(
      icon: Icon(Icons.skip_previous),
      onPressed: _audioPlayer.hasPrevious ? _audioPlayer.seekToPrevious : null,
    );
}

Widget _nextButton() {
    return IconButton(
      icon: Icon(Icons.skip_next),
      onPressed: _audioPlayer.hasNext ? _audioPlayer.seekToNext : null,
    );
}

Every time the stream _audioPlayer.sequenceStateStream emits a new value, we check whether the current playing source has a previous and a next source in the sequence, and, if so, we set onPressed to the relevant method of _audioPlayer.

The fourth and last method is the one that creates the loop button.

Widget _repeatButton(BuildContext context, LoopMode loopMode) {
    final icons = [
      Icon(Icons.repeat),
      Icon(Icons.repeat, color: Theme.of(context).accentColor),
      Icon(Icons.repeat_one, color: Theme.of(context).accentColor),
    ];
    const cycleModes = [
      LoopMode.off,
      LoopMode.all,
      LoopMode.one,
    ];
    final index = cycleModes.indexOf(loopMode);
    return IconButton(
      icon: icons[index],
      onPressed: () {
        _audioPlayer.setLoopMode(
            cycleModes[(cycleModes.indexOf(loopMode) + 1) % cycleModes.length]);
      },
    );
}

Every time the button is pressed, we cycle through a list of loop modes: off, all, and one.

More buttons

Now the app is more interesting. But it is still missing a lot.

In the next article of the series, we will start improving the project repositoryand we will add the list of audio sources, or playlist, that we are playing.

This is the full code for this article.

// main.dart

import 'package:flutter/material.dart';
import 'package:music_player/screens/player.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Player(),
    );
  }
}
// player.dart

import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:music_player/screens/commons/player_buttons.dart';

class Player extends StatefulWidget {
  @override
  _PlayerState createState() => _PlayerState();
}

class _PlayerState extends State<Player> {
  AudioPlayer _audioPlayer;

  @override
  void initState() {
    super.initState();
    _audioPlayer = AudioPlayer();

    _audioPlayer
        .setAudioSource(ConcatenatingAudioSource(children: [
      AudioSource.uri(Uri.parse(
          "https://archive.org/download/IGM-V7/IGM%20-%20Vol.%207/25%20Diablo%20-%20Tristram%20%28Blizzard%29.mp3")),
      AudioSource.uri(Uri.parse(
          "https://archive.org/download/igm-v8_202101/IGM%20-%20Vol.%208/15%20Pokemon%20Red%20-%20Cerulean%20City%20%28Game%20Freak%29.mp3")),
      AudioSource.uri(Uri.parse(
          "https://scummbar.com/mi2/MI1-CD/01%20-%20Opening%20Themes%20-%20Introduction.mp3")),
    ]))
        .catchError((error) {
      // catch load errors: 404, invalid url ...
      print("An error occured $error");
    });
  }

  @override
  void dispose() {
    _audioPlayer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: PlayerButtons(_audioPlayer),
      ),
    );
  }
}
// player_buttons.dart

import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';

class PlayerButtons extends StatelessWidget {
  const PlayerButtons(this._audioPlayer, {Key key}) : super(key: key);

  final AudioPlayer _audioPlayer;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        StreamBuilder<bool>(
          stream: _audioPlayer.shuffleModeEnabledStream,
          builder: (context, snapshot) {
            return _shuffleButton(context, snapshot.data ?? false);
          },
        ),
        StreamBuilder<SequenceState>(
          stream: _audioPlayer.sequenceStateStream,
          builder: (_, __) {
            return _previousButton();
          },
        ),
        StreamBuilder<PlayerState>(
          stream: _audioPlayer.playerStateStream,
          builder: (_, snapshot) {
            final playerState = snapshot.data;
            return _playPauseButton(playerState);
          },
        ),
        StreamBuilder<SequenceState>(
          stream: _audioPlayer.sequenceStateStream,
          builder: (_, __) {
            return _nextButton();
          },
        ),
        StreamBuilder<LoopMode>(
          stream: _audioPlayer.loopModeStream,
          builder: (context, snapshot) {
            return _repeatButton(context, snapshot.data ?? LoopMode.off);
          },
        ),
      ],
    );
  }

  Widget _playPauseButton(PlayerState playerState) {
    final processingState = playerState?.processingState;
    if (processingState == ProcessingState.loading ||
        processingState == ProcessingState.buffering) {
      return Container(
        margin: EdgeInsets.all(8.0),
        width: 64.0,
        height: 64.0,
        child: CircularProgressIndicator(),
      );
    } else if (_audioPlayer.playing != true) {
      return IconButton(
        icon: Icon(Icons.play_arrow),
        iconSize: 64.0,
        onPressed: _audioPlayer.play,
      );
    } else if (processingState != ProcessingState.completed) {
      return IconButton(
        icon: Icon(Icons.pause),
        iconSize: 64.0,
        onPressed: _audioPlayer.pause,
      );
    } else {
      return IconButton(
        icon: Icon(Icons.replay),
        iconSize: 64.0,
        onPressed: () => _audioPlayer.seek(Duration.zero,
            index: _audioPlayer.effectiveIndices.first),
      );
    }
  }

  Widget _shuffleButton(BuildContext context, bool isEnabled) {
    return IconButton(
      icon: isEnabled
          ? Icon(Icons.shuffle, color: Theme.of(context).accentColor)
          : Icon(Icons.shuffle),
      onPressed: () async {
        final enable = !isEnabled;
        if (enable) {
          await _audioPlayer.shuffle();
        }
        await _audioPlayer.setShuffleModeEnabled(enable);
      },
    );
  }

  Widget _previousButton() {
    return IconButton(
      icon: Icon(Icons.skip_previous),
      onPressed: _audioPlayer.hasPrevious ? _audioPlayer.seekToPrevious : null,
    );
  }

  Widget _nextButton() {
    return IconButton(
      icon: Icon(Icons.skip_next),
      onPressed: _audioPlayer.hasNext ? _audioPlayer.seekToNext : null,
    );
  }

  Widget _repeatButton(BuildContext context, LoopMode loopMode) {
    final icons = [
      Icon(Icons.repeat),
      Icon(Icons.repeat, color: Theme.of(context).accentColor),
      Icon(Icons.repeat_one, color: Theme.of(context).accentColor),
    ];
    const cycleModes = [
      LoopMode.off,
      LoopMode.all,
      LoopMode.one,
    ];
    final index = cycleModes.indexOf(loopMode);
    return IconButton(
      icon: icons[index],
      onPressed: () {
        _audioPlayer.setLoopMode(
            cycleModes[(cycleModes.indexOf(loopMode) + 1) % cycleModes.length]);
      },
    );
  }
}

The app code is also available on GitHub.

Get a weekly email about Flutter

Subscribe to get a weekly curated list of articles and videos about Flutter and Dart.

    We respect your privacy. Unsubscribe at any time.

    Leave a comment